iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0

對於 Lisp ,我有個看法繼承自 Clojure 社群:

開發應用軟體,要儘量少用 Lisp Macro 。

如果是 Common Lisp 社群的話,很可能覺得這種看法簡直是胡說八道, 因為在 Common Lisp 社群普遍認為:「Macro 是 Lisp 的精髓所在,它們不只是語法糖,而是讓工程師能夠為特定問題定義新語言,最後讓程式碼成為更能表達其意圖的絕佳工具。」

此外,讀者可能還讀過了《黑客與畫家》一書,也讀過了 Paul Graham 大力誦揚 Lisp 的一篇文章:Beating The Averages。確實,那一篇文章就是講 Lisp 因為有 Lisp Macro ,所以可以讓軟體工程師的生產力超越平庸啊。而且,Paul Graham 還舉証:「在 Viaweb 的程式碼庫中,有高達 20-25% 的程式碼是 Macro。」

這部分的爭辯,不妨先擱置,等學會了 Macro 再來研究要不要積極使用。

Lisp 語言提供了哪些 Macro ?

談論 Macro 之前,先舉兩類比較有代表性的語法:

  1. {:key value}[:a :b :c] :這兩種宣告 Table 的語法稱之為 Literal Syntax。它們不是 S 表達式。這類型的語法,會由 Lisp 編譯器在 read 階段處理。如果某個 Lisp 編譯器有提供 Reader Macro 的話,Reader Macro 就可以做出類似的 Literal Syntax。
  2. -> :這是 Thread first 的語法。這種語法是 S 表達式的形式,所以可以透過 Lisp Macro 來實現。

下圖是一個從源碼到可運作程式的流程圖,它可以拆分成兩個階段的工作:

  • 階段一:語法分析與生成 syntax tree,而 Reader Macro 在此階段運作。Reader Macro 是一種 Reader 的擴充機制,因而可以提供新的語法 (syntax)。
  • 階段二:語意分析與編譯,而 Lisp Macro 在此階段運作。Lisp Macro 是一種編譯器的擴充機制,因而可以提供新的語意 (semantics)。

多數的時候,如果我們沒有特別註明 Reader Macro 時,我們所談論的 Macro 都是指運作在階段二的 Lisp Macro 。

https://ithelp.ithome.com.tw/upload/images/20250910/201618693ySoPpQwZ4.png

新的語法 (呈現形式)

在 Lua 宣告 Table 的語法是:

local fruits = {"apple", "banana", "orange"}

local person = {
  name = "Alice",
  age = 30,
  city = "Taipei"
}

對應的 Fennel 語法是:

(local fruits [:apple :banana :orange])
(local person {:age 30 :city :Taipei :name :Alice})	

而 Reader Macro 運作原理如下:

當讀取器 (Reader) 在讀取 (read) 源碼時,當它讀到了 […] 字元時,它就會觸發 [...] 對應的 Reader Macro ,於是將其解析成 (sequential-table ...);同樣的道理,當它讀到了 {…} 字元時,它就會觸發 {...} 對應的 Reader Macro ,於是將其解析成 (general-table ...)。(此處是假設上述的 Fennel 語法是透過 Reader Macro 來實作,但是實質上 Fennel 是直接實作在 Reader 之內)

觀察上頭的例子,可以發現,Reader Macro 只改變了程式碼的呈現形式,換言之,它只提供了新語法。

新的語意 (行為)

相對於 Reader Macro ,Lisp Macro 的核心能力在於定義新的語意,也就是『改變程式碼的行為,而不只是改變呈現形式。』讓我們以 Fennel 的 Thread First Macro -> 為例,解釋這個概念。

在 Fennel 裡,如果你想對一個資料進行一系列操作,你會將函數呼叫層層嵌套,從內往外寫。例如,你想先將一個數字加一,再乘以十,最後取絕對值:

(math.abs (* 10 (+ 1 5)))

這段程式碼的閱讀順序是從內層開始往外讀的:(+ 1 5)(* 10 ...)(math.abs ...)。這種從內到外的寫法,對於複雜的連續操作來說,會讓程式碼變得難以閱讀和理解。

Fennel 的 -> Macro 解決了這個問題,它讓你能用一種更自然、更線性的方式來表達相同的邏輯:

(-> 5
  (+ 1)
  (* 10)
  math.abs)

這就是 Macro 提供新語意的絕佳例子。標準的 Lisp 並沒有內建「將前一個運算的結果作為參數,傳給下一個運算」的語法。-> 是一個 Macro,它的作用是在編譯階段,將你寫的線性程式碼:

(-> 5 (+ 1) (* 10) math.abs)

重寫成嵌套的 S 表達式:

(math.abs (* 10 (+ 1 5)))

這個重寫的過程,就是 Macro 賦予程式碼新行為的體現。它不僅僅是替換掉一些字元,而是創造出一個全新的運算流程和邏輯。

Fennel 的 Reader Macro

在 Fennel 的官網也有隱含地提到 Reader Macro ,不過是用極簡主義的方式提。

The parse-error and assert-compile hooks can be used to override how fennel behaves down to the parser and compiler levels. Possible use-cases include building atop fennel.view to serialize data with EDN-style tagging, or manipulating external s-expression-based syntax, such as tree-sitter queries.

我來翻譯一下:parse-errorassert-compile 這兩個 hooks 可以在 Fennel 的 parser 和 compiler 階段加入新的行為。

再論 Lisp Macro

其實很多的程式語言,都有 meta programming 這種進階議題。換言之,這些可以做 meta programming 的程式語言,也提供了等價於 Lisp Macro 的功能:也就是用程式來寫程式。

然而,可以做到等價的事,不表代等價的輕鬆。Lisp Macro 絕對是表達能力最強的、寫起來最輕鬆的。

Lisp Macro 最大的特色就是:你所做的事情就是定義一個函數,將一組語法樹轉換成另一組語法樹。

比方說前述的 Thread Macro -> ,它的引數是:

5 (+ 1) (* 10) math.abs

引數是一組語法樹。

而它的輸出是:

(math.abs (* 10 (+ 1 5)))

輸出又是另一組語法樹。

撰寫 Lisp Macro

由於在真實的軟體開發應用情境裡,真的需要寫 Lisp Macro 的機率並不高。如何撰寫的部分,就留給讀者自行研究。

另一方面,有一類特殊的 Lisp Macro 用法,卻是 Clojure 社群也鼓勵多多使用的,它們可稱之為 With-Macro 。

With-Macro

比方說,Fennel 就有一個 with-open 語法,這就是一種 With-Macro 。

用法如下:

;; Basic usage
(with-open [fout (io.open :output.txt :w) fin (io.open :input.txt)]
  (fout:write "Here is some text!\n")
  ((fin:lines))) ; => first line of input.txt

它的功能是會在結束檔案讀取時,幫忙處理關閉檔案、釋放資源的動作。換言之,它雖然也做『行為』的改變,但是,它所做的行為改變,通常只有在前後做一些上下文的管理。

這類型的 With-Macro 在處理和副作用相關的函數呼叫時,特別地有用。

在 Python 語言,並沒有 Lisp Macro ,但是卻有相當於 With-Macro 的設計,它叫做 Context Manager。

# 沒有使用 with 的寫法
f = open('test.txt', 'r')
try:
    content = f.read()
    # 即使這裡發生錯誤,finally 區塊的 f.close() 依然會被執行。
    # 這種寫法比較繁瑣,需要手動處理資源關閉。
    print(content)
finally:
    f.close()

---

# 使用 with 的寫法
with open('test.txt', 'r') as f:
    content = f.read()
    # 這裡的程式碼如果發生錯誤,`with` 會確保 `f` 物件的
    # `__exit__` 方法被自動呼叫,從而正確關閉檔案。
    # 這種寫法更簡潔、安全。
    print(content)

小結

本篇解釋了幾個重要的概念:Reader Macro、Lisp Maco、程式碼的呈現形式、程式碼的行為、With-Macro 。以及,程式語言的讀取和編譯到底是怎樣的過程。

當你下回聽到別人想要設計或是開發新的程式語言或是 DSL 時,不妨問問他:「你有學過 Lisp 嗎?」

如果答案是 Yes,那這個人是有先做過功課的。


上一篇
Lisp 深入淺出—S 表達式編輯
下一篇
Lisp 深入淺出—資料導向編程
系列文
在 Neovim 中探索 Fennel 與函數式編程21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言